今天要把自由散步的基本流程收尾。
最簡單的做法,是在完成頁再放一個 MapView,並使用 Polyline 顯示路線,也就是前面顯示散步軌跡時使用的方式:
<MapView>
<Polyline coordinates={coordinates} />
</MapView>
但如果只是要顯示一條靜態路線,重新載入完整的地圖元件就有點多餘。
因此,我決定改用 react-native-svg 繪製路線簡圖,直接將 GPS 點位轉換成 SVG 座標,再畫成一張靜態路線縮圖。
整體流程如下:
GPS 點位
↓
找出最大與最小經緯度
↓
計算路線範圍
↓
修正經度比例
↓
計算共用縮放值
↓
計算置中偏移
↓
轉換成 SVG 座標
↓
使用 Polyline 繪製
GPS 點位使用的是經緯度:
{
latitude: 25.033,
longitude: 121.5654,
}
SVG 使用的則是畫布座標:
<Svg viewBox="0 0 100 100">
因此,需要先確認整條路線在經度和緯度方向上的分布範圍。
首先取出所有點位的經緯度:
const latitudes = coordinates.map(
point => point.latitude,
);
const longitudes = coordinates.map(
point => point.longitude,
);
接著找出最大值與最小值:
const minLat = Math.min(...latitudes);
const maxLat = Math.max(...latitudes);
const minLng = Math.min(...longitudes);
const maxLng = Math.max(...longitudes);
這四個值就代表整條路線的邊界。
取得最大值與最小值後,就能計算路線在垂直方向跨了多少緯度:
const latRange = maxLat - minLat;
例如:
maxLat = 25.040
minLat = 25.030
latRange = 0.010
代表這條路線在緯度方向橫跨了 0.01°。
經度方向也可以先用相同方式計算:
const lngRange = maxLng - minLng;
不過,經度和緯度雖然都是用度表示,實際代表的距離卻不完全相同,因此 lngRange 還不能直接拿來和 latRange 比較。
一度緯度在地球上代表的距離大致固定,而一度經度則會隨著所在緯度改變,越接近兩極就越短。
例如在台灣,大約位於北緯 22°~25°,一度經度大約是 101~103 公里;如果是在赤道附近,則大約是 111 公里。
如果直接把經度差與緯度差當成相同比例處理,畫出的路線就可能產生變形。
一度經度的實際距離,可以近似看成一度緯度乘上 cos(緯度),因此這裡使用整條路線的平均緯度,計算經度方向的縮放比例:
const centerLat = (minLat + maxLat) / 2;
const longitudeScale = Math.cos(
centerLat * Math.PI / 180,
);
longitudeScale 代表目前緯度下一度經度,相較於赤道還剩下多少比例。
例如在北緯 25°:
cos(25°) ≈ 0.906
代表一度經度的實際距離,大約是一度緯度的 90.6%。
Math.cos() 接收的是弧度,而 GPS 提供的緯度是角度,因此需要先將角度轉成弧度,也就是乘上 Math.PI / 180。
接著將原本的經度範圍乘上修正比例:
const adjustedLngRange =
lngRange * longitudeScale;
現在兩個方向的範圍分別是:
const latRange = maxLat - minLat;
const lngRange = maxLng - minLng;
const adjustedLngRange =
lngRange * longitudeScale;
其中:
latRange:垂直方向的路線範圍adjustedLngRange:修正後的水平方向路線範圍這樣才能正確比較整條路線是偏寬還是偏高。
完成經度修正後,接著要決定整條路線要放大到什麼程度。
如果水平和垂直方向分別縮放到填滿畫布,原本很小的水平或垂直偏移就可能被過度放大,導致路線失去原本的形狀。
因此,水平與垂直方向必須共用同一個縮放比例:
const viewBoxSize = 100;
const drawableSize = 84;
const routeRange = Math.max(
latRange,
adjustedLngRange,
);
const scale = drawableSize / routeRange;
routeRange 會取水平與垂直範圍中較大的那一個。
例如路線比較高:
latRange = 0.010
adjustedLngRange = 0.004
就會使用 0.010 作為縮放基準。
這樣較大的方向會使用完整的可繪製空間,較小的方向則按照相同比例縮放,因此能保留路線原本的長寬比例。
這裡將 drawableSize 設為 84,也就是在 100 × 100 的畫布中預留一部分空間,避免路線和起終點貼到邊緣。
不過,由於路線會等比例縮放,較短的方向不一定會剛好使用完整的 84 個單位,因此還要另外計算水平與垂直方向的置中偏移:
const actualSvgWidth =
adjustedLngRange * scale;
const actualSvgHeight =
latRange * scale;
const offsetX =
(viewBoxSize - actualSvgWidth) / 2;
const offsetY =
(viewBoxSize - actualSvgHeight) / 2;
剩餘空間會平均分配到路線兩側,讓路線不論是偏寬還是偏高,都能顯示在畫布中央。
SVG 的座標原點位於左上角,y 值越大,位置就越往下。
但經緯度在轉成地圖位置時,緯度越大代表位置越北,顯示在畫面上時也應該越靠上方。
如果直接以最南邊的 minLat 作為基準:
point.latitude - minLat
緯度越大的點,算出的值也會越大,結果越北的點反而會被畫到越下面,整條路線就會上下顛倒。
因此,需要改成以最北邊的 maxLat 作為基準:
maxLat - point.latitude
這樣最北的點會得到最小的 y 值,最南的點則會得到最大的 y 值,繪製方向才會和地圖一致。
完成前面的計算後,就可以將每一個 GPS 點位轉成 SVG 座標:
const svgPoints = coordinates.map(point => {
const x =
offsetX +
(point.longitude - minLng) *
longitudeScale *
scale;
const y =
offsetY +
(maxLat - point.latitude) *
scale;
return { x, y };
});
x 座標的計算流程是:
目前經度與最小經度的差
× 經度修正比例
× 畫布縮放比例
+ 水平置中偏移
y 座標則是:
最大緯度與目前緯度的差
× 畫布縮放比例
+ 垂直置中偏移
這樣不管路線是偏寬、偏高,還是接近正方形,都能在保留原本比例的情況下顯示在畫布中央。
完成轉換後,原本的 GPS 點位:
{
latitude: 25.033,
longitude: 121.5654,
}
就會變成 SVG 可以使用的座標:
{
x: 42.7,
y: 35.6,
}
接著將所有轉換後的點位組成 Polyline 所需的座標字串:
const path = svgPoints
.map(point => `${point.x},${point.y}`)
.join(' ');
產生的結果會類似:
8,92 24.5,68.2 42.7,35.6 76.1,8
最後交給 SVG 繪製:
<Polyline
points={path}
fill="none"
stroke="#34322E"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
/>
另外再使用兩個圓形標記第一個點與最後一個點,就能讓使用者分辨路線的起點與終點。
完成路線縮圖後,自由散步的基本流程也算告一段落了。
開始散步
↓
持續接收 GPS 點位
↓
過濾並暫存點位
↓
批次同步到後端
↓
暫停與繼續
↓
結束散步
↓
後端計算正式統計
↓
顯示統計與路線縮圖
到這裡,使用者已經可以完整走完一次自由散步。
從開始記錄 GPS 點位,到過濾、同步與結束散步,整個流程都已經串起來。散步完成後,也能看到距離、時間、速度與路線縮圖,基本功能就算完整了。